fix(ci): stop the benchmark suite starving CI and publishing partial numbers - #350
Conversation
…numbers Three open issues on 2.4.0 turned out to be one lane: nothing rationed the most expensive workflow in the repository. Runs accumulated. benchmarks.yml declared no concurrency group, so five pushes over one review loop created five uncancelled eight-shard runs and left CI and Coverage queued for ~50 minutes behind numbers nobody would read. The workflow now supersedes its own in-flight run, keyed on the PR number; the main path is keyed on the commit SHA instead, so every commit is its own group, cancel-in-progress can never discard one, and the published series keeps every point. Closes #319. Runs overlapped. With three branches in flight every shard measured 1.75-2.0x its baseline and one hit the 120-minute cap - on two pull requests whose diffs were XML doc comments only. The quiet failure is the worse one: a shard that finishes under uneven contention still publishes its skewed delta. scripts/benchmark_relevant_changes.js now gates the PR path and does not run the suite when the diff cannot move a number. It is one-directional by construction (only documentation, the three projects Celerity.Benchmarks.csproj does not reference, and .cs files whose text is unchanged once comments are stripped can be skipped) and never applies to main, so a gate mistake costs a missing PR comment rather than an unseen regression. It correctly skips both pull requests the issue names and runs on every code change it was tested against. The issue's own first choice - a global serialize-everything concurrency group - was deliberately not taken: GitHub queues at most one pending run per group and cancels the older pending one, so serializing would silently drop runs. What ships is its option 2, which it rated cheapest and most obviously correct. Closes #335. The two sides of the A/B packed from different class lists. Greedy bin-packing is a function of the whole list and the PR head has a class main does not, so shard i was not the same slice on both sides and could pair a light head slice with a heavy base one. The base now replays the class list the head resolved, which makes it a subset of the head by construction: the pair is bounded by twice the head slice, the quantity the packer already balances. Option 4 of that issue ships alongside - a report missing a shard says so above the fold, since a partial comparison previously read exactly like a complete one. Closes #300. The comment-stripping rests on a real C# scanner rather than a //-prefix test, because // occurs inside literals and the verbatim / interpolated / raw forms desynchronise a guess; a --self-test pins it in a new benchmark-gate job. --shard-dry-run resolves a shard's class list without measuring, so the packing is inspectable without a multi-hour run.
Coverage
|
There was a problem hiding this comment.
Pull request overview
This PR hardens the repository’s benchmark CI pipeline so it no longer starves other workflows, avoids running on diffs that cannot affect measured performance, and prevents publishing “quietly partial” benchmark comparisons.
Changes:
- Add workflow-level concurrency and a PR-only relevance gate to avoid stacked/overlapping benchmark runs and to skip inert diffs.
- Make benchmark shard membership consistent between PR head/base by replaying the head’s resolved class roster, and add an explicit “incomplete report” warning path.
- Update contributor/docs/changelog/roadmap documentation to reflect the new CI behavior and operational expectations.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/Celerity.Benchmarks/Program.cs |
Adds shard roster recording/replay and a dry-run mode so head/base compare the same slice and packing can be inspected without running benchmarks. |
scripts/benchmark_relevant_changes.js |
New gate script to decide whether a PR diff can affect benchmark numbers (comment-only, docs-only, or unreferenced-project changes can skip). |
ROADMAP.md |
Records the milestone “done” item summarizing the three related benchmark CI issues and the chosen design decisions. |
docs/testing.md |
Updates the workflow table to document benchmark run superseding + skip behavior on inert PR diffs. |
docs/performance.md |
Clarifies that the lean CI suite runs only when the PR can move numbers (while main always measures). |
CONTRIBUTING.md |
Documents benchmark CI behavior (superseding, relevance gate, and shard slice parity) for contributors. |
CHANGELOG.md |
Notes the new benchmark relevance gate and shard dry-run, and documents fixes for benchmark CI starvation/timeouts/partial reports. |
.github/workflows/ci.yml |
Adds a lightweight benchmark-gate job to pin the gate script’s lexer via --self-test. |
.github/workflows/benchmarks.yml |
Adds concurrency, introduces the relevance-gate job, replays shard class rosters for base runs, and makes partial reports loud (warnings + PR comment banner). |
Both from the Copilot review, both correct. The unbenchmarked-project list named only three of the six projects the benchmark process cannot load, so a change confined to Celerity.Ring.Tests, Celerity.Sentinel.Tests or Celerity.Cardinality.Tests still bought a full sharded run. Rather than append three more strings to a list that had already drifted once, the rule is now stated as the reachable set (BENCHMARKED_PROJECTS, following Celerity.Benchmarks.csproj transitively) plus a convention for the rest, and --self-test refuses to pass while any project directory under src/ is classified by neither. A project that fits no rule is still treated as significant, so the drift guard fails loudly without ever making the gate less safe. normalize() trimmed every line after comment-stripping, which is right for code and wrong inside a multi-line verbatim or raw literal, where the indentation and the blank lines are part of the string value and reach the IL. Reindenting such a literal therefore read as "comments only". A newline inside a string frame is now emitted as a sentinel instead, so each literal occupies one logical line and the per-line trim can only touch the code around it. Pinned both ways: reindenting a literal must not normalize identically, and a comment edit beside one must. Also batched from my own re-read: the relevance-gate job no longer does a full-history checkout on the push path, where it answers without consulting the repository at all and the clone was pure latency ahead of the matrix; and the gate's git invocations set core.quotePath=false so a path with non-ASCII characters is compared exactly rather than taking the fail-safe run path. Historical verdicts are unchanged: the two documentation-only pull requests still skip and every code change still runs.
The concurrency fix verified itself on this PRPushing On The relevance gate also ran on both pushes and reported What is still outstanding on this PR is the part that can only be proven by letting it finish: the 8-shard matrix on |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Celerity.Benchmarks/Program.cs:209
--shard-classesis documented as replaying the head slice, but the current implementation reads the file into aHashSet<string>, which drops ordering (and duplicates). That means the base side won’t actually replay the slice verbatim, and the run order can differ from the head’s packed order.
Preserve the file’s line order when rebuilding slice (and emit missing-class messages per line) so replay is deterministic and truly matches what the head resolved.
var wanted = new HashSet<string>(
File.ReadAllLines(replayPath)
.Select(line => line.Trim())
.Where(line => line.Length > 0),
StringComparer.Ordinal);
CHANGELOG.md:32
- This changelog entry is much longer than the project’s documented convention (“keep each entry short and user-facing — a few sentences at most”; see CONTRIBUTING.md:181). Consider tightening it to the observable behavior change and why it matters, and keep the detailed incident narrative in the PR body instead.
- Pushing to a pull request no longer stacks another eight-runner benchmark matrix behind the last — the workflow supersedes its own in-flight run. Five pushes over one review loop had left `CI` and `Coverage`, the checks that actually gate correctness, queued for ~50 minutes behind perf numbers nobody would read. Pushes to `main` are keyed per commit instead, so none is ever cancelled and the published history keeps every point. Closes [#319](https://github.com/marius-bughiu/Celerity/issues/319).
…entries Both from the second Copilot review, which reported "no new comments" and then carried two in a suppressed block. --shard-classes read the roster into a HashSet and filtered the suite by it, so the base ran the right classes in the wrong order: declaration order rather than the packed order the head wrote. The set is what bounds the job, so this was not the timeout bug, but running the two sides in different orders reintroduces in miniature the systematic head/base difference the whole handoff exists to remove. The roster is now walked in file order, de-duplicated on the way, and a name this side does not have is reported where it appears rather than in a separate sorted pass. Verified: head shard 1 and its replay now resolve to the identical sequence, where before the replay came back reordered. The changelog entries ran to the incident narrative that CONTRIBUTING.md says belongs in the PR body — a release-safety rule here, not only style, since the release workflow extracts a whole version section verbatim as the GitHub release body. Five entries condensed to what observably changed and why it matters; the narrative is already in the PR description.
Re: the second review's two suppressed commentsBoth were right and both are fixed in 9aa3672. Answering here since suppressed comments have no thread to reply on. 1. Correct, and I had missed it. The implementation read the roster into a The set is what bounds the job, so this was not the timeout bug — but running the two sides in different orders reintroduces, in miniature, exactly the systematic head/base difference the roster handoff exists to remove. Not something I want in a same-runner A/B. The roster is now walked in file order into a 2. The changelog entries were too long ( Also correct, and it is the rule I should have applied — All five entries are condensed to the observable change and why it matters; the narrative stays in the PR description above. The |
… measurement The first full run of this workflow found a real defect in it, which no amount of local testing could have: every shard was cancelled at the 120-minute cap. The base step builds a worktree at the `main` tip and runs THAT code, so it only understands flags already on `main`. Switching the base from --shard to --shard-classes meant a base that predates the flag matched nothing, fell through to the whole suite, and ran until the job timeout: shard 0's head measured 3 classes and 97 benchmarks in 66 minutes, then the base logged "Found 12 benchmarks" nine times over in 53 minutes before being killed. This is the same transitional trap the comment I replaced had been warning about for --shard itself. Both selectors are now passed, so the base packs its own slice until this merges and replays the head's afterwards, and the constraint is written down where the next selector will be added. The run also measured what the cap should be. The eight head slices took 45.8, 48.7, 55.4, 55.5, 56.8, 58.2, 63.9 and 67.3 minutes; the base replays the head's slice, so a job costs about twice its head slice, and shards 0 and 2 exceed 120 minutes on their own. The cap was set when the suite was smaller and Sorting, SortedSpan and SegmentTree have each added classes since, so 120 -> 180 is sizing the budget to the bound rather than buying room for an imbalance -- the imbalance is what the roster handoff fixes, and it had to exist first for this number to mean anything. Widening the matrix was the alternative and is rejected in the comment: the 81-case StringHasherBenchmark is one class and sharding is by class, so it floors the heaviest slice however many shards there are, and more shards means more concurrent runners -- the contention this change exists to reduce. If slices reach 90 minutes the fix is to split that class, not to raise this again. Verified locally that the new code still prefers the roster when both flags are present: with --shard 5 and shard 0's roster it resolves to shard 0's three classes in order, where --shard 5 alone packs a different seven.
The first full run found a real bug in this PR — fixed in f06f4b1Letting the 8-shard matrix run to completion was worth it: every shard was cancelled at the 120-minute cap, and the cause was a defect I introduced. What went wrong. The base step builds a worktree at the This is the same transitional trap the comment I replaced had been warning about for The run also measured what the timeout should be, which is the part I could not have known when I argued against #300's option 3. Head slices on this run:
Because the base replays the head's slice, a job costs about twice its head slice — and shards 0 and 2 exceed 120 minutes on their own, with the imbalance already fixed. The cap dates from a smaller suite; I rejected widening the matrix instead, and said why in the comment: the 81-case One thing worth calling out as a positive: Verified locally that precedence is right when both flags are present: |
Benchmarks8 regressions Highlights
Collections (646)
Hashers (111)
Same-runner A/B (sharded 8-way): main ( |
Full run green — all 21 checks, including all 8 benchmark shardsThe The base step measures a slice again, not the suite. Shard 6, head against base on the same runner: Same count, same duration. On the previous run the base was 53 minutes into the whole suite when the job was killed. The timeout raise was necessary, not cosmetic. Final job durations against the old 120-minute cap:
Three shards exceeded 120 minutes and would have been cancelled, with the imbalance already fixed and effectively no competing runs — and shard 2 cleared it by 1.1 minutes, which is not a margin. The heaviest is 126.8 min against the new 180-minute budget, leaving ~30% headroom. Everything else validated live during the review loop:
One thing for you rather than for this PRThe comparison comment on this PR flags 8 regressions and 5 improvements — on a diff whose library IL is byte-identical to The cause looks structural rather than incidental: the guard compares a between-run mean shift against BenchmarkDotNet's I have not touched it here — it is a separate change to the reporting half, and this PR is already three issues wide. Filed as #351 with the numbers and five approaches. Please read this PR's own benchmark comment as noise. Nothing outstanding from the review loop: four Copilot rounds, four findings, all fixed and answered, no threads left open. |
Three open issues on milestone 2.4.0 — #319, #335 and #300 — turned out to be one lane: nothing rationed the most expensive workflow in the repository, and the failures compounded. Fixing them separately would have meant three PRs touching the same forty lines of
benchmarks.yml, so they ship together.What was wrong
Runs accumulated (#319).
benchmarks.ymldeclared no concurrency group, so every push to a PR branch started another full 8-shard run and none of the superseded ones were cancelled. Five pushes over one review loop created five live runs — up to 24 runner slots — and leftCIandCoverage, the checks that actually gate correctness, queued for ~50 minutes behind perf numbers nobody would read. Only the newest run's numbers are ever read, so the rest was pure waste.Runs overlapped (#335). With three branches' runs in flight, every shard measured 1.75–2.0× its baseline and one hit the
timeout-minutes: 120cap — on two pull requests whose diffs were XML doc comments only. The loud failure is a cancelled shard; the quiet one is worse, because a shard that completes under uneven contention still publishes its skewed delta to the dashboard, and the same-runner A/B invariant only cancels hardware when both sides see the same neighbour load.The two sides of the A/B packed from different class lists (#300). Shard membership comes from greedy bin-packing over the benchmark class list, and the PR head has a class
maindoes not. So shard i was not the same slice on the two sides, a job could draw a light head slice and a heavy base slice, and the pair overran its budget even when every individual slice was well inside it. This recurs for every PR that introduces a benchmark class — which is every new collection, the repo's most common feature shape.What changed
1. The workflow supersedes its own run —
.github/workflows/benchmarks.ymlOne group expression, two behaviours. On a PR the key is the PR number, so a push cancels the previous run. On
mainthe key is the commit SHA, so every commit is its own group andcancel-in-progresscan never discard one — each commit's numbers are independently meaningful there, and a cancelled run would leave a hole in the gh-pages time series.2. The suite does not run when the diff cannot move a number —
scripts/benchmark_relevant_changes.jspaths: src/**is a path filter, not a semantic one: an XML doc-comment edit is asrc/**change and buys a full sharded A/B run for a diff with zero IL in it. The new gate is one-directional — skipping is only claimed when every changed path is one of:src/(docs, the dashboard, the changelog);Celerity.Benchmarks.csprojdoes not reference —Celerity.Tests,Celerity.Fuzz,Celerity.AotSmokeTest, so nothing in them can reach a measurement;.csfile whose text is unchanged once comments are stripped.Everything else runs: an added or deleted file, a rename, a non-
.csfile undersrc/, the workflow itself, this script, a failing git command, a missing argument. And it is applied to the pull-request path only —mainalways measures, so a wrongly-skipped PR is still measured on merge. That caps the worst case of a gate mistake at "the PR comment was missing", never "the regression was never seen".Comment-stripping is a real C# scanner with a mode stack, not a
//-prefix test://occurs inside literals, and the verbatim / interpolated / raw / interpolated-raw forms desynchronise a guess.--self-testpins 23 cases (escaped quotes,@"a ""//"" b",$"{dict["k"]}",$$"""{{x}} // text""",'\'',@class) and runs in a newbenchmark-gatejob inci.yml.Verified against the actual history, not just unit-tested:
run=false✅run=false✅e94db0dfeat: add SegmentTreerun=true✅5647031fix(PartialSort): TopK alias guardrun=true✅d646716docs(SegmentTree) — comment + md onlyrun=false✅run=true✅Both PRs that caused the #335 incident would have been skipped entirely.
What I deliberately did not do. #335's option 1 was a global
concurrency: { group: benchmarks, cancel-in-progress: false }to serialize the workflow. GitHub queues at most one pending run per group and cancels the older pending one, so global serialization would silently drop runs — a worse failure than slow feedback, and it defeats the point of measuring. What ships is the issue's option 2, which it rated "the cheapest win and independently worthwhile" and preferred doing first. Option 3 (raise the timeout) treats the symptom; the issue says so itself.3. Shard i is the same slice on both sides —
src/Celerity.Benchmarks/Program.csThe base replays the class list the head resolved (
--shard-classes) instead of packing its own (--shard-classes-outwrites it). That makes the base a subset of the head by construction, so the pair is bounded by twice the head slice — the quantity the packer already balances — and shard i compares like with like. A class the PR adds is simply absent from the base's suite; the process says so and skips it, and the comparison reports it as🆕 newrather than as a delta. This is the issue's option 1, chosen over option 3 (raise the timeout), which only moves the ceiling.--shard-dry-runresolves a shard's class list and stops. The packing was previously observable only by running the suite for hours, which is a large part of why the head/base mismatch went unnoticed.4. A partial report is loud — option 4 of #300
The aggregate job runs with
if: always(), so a cancelled shard produced a report that was legitimately missing whole benchmark classes and read exactly like a complete one. The merge step now names the missing shard indices, emits a::warning::annotation, and the PR comment leads with a> [!WARNING]block above the fold before any numbers.Parity
This is CI/infrastructure work with no new collection, public API or hasher, so most of the collection parity checklist does not apply — there is no dedicated collection test file, no cross-collection shared-test row, no new
XxxBenchmarkclass, noCOLLECTIONSdashboard entry and nodocs/api/collections.mdsection to add. What does apply:--self-teston the new script, wired into abenchmark-gatejob inci.yml, mirroring howcheck_doc_anchors.jsis guarded.CONTRIBUTING.md(three new bullets under CI: it supersedes itself, it is skipped on inert diffs, shard i means the same slice),docs/testing.md(the workflow table row),docs/performance.md(the core-suite description).### Addedand three### Fixedbullets.doneentry under 2.4.0's "Build- and release-pipeline integrity" group recording all three issues and the three design calls above.Test plan
dotnet build— 0 errors (3582 pre-existing warnings, tracked in Test project emits 3264 build warnings, burying 20 real CS8631 nullability warnings #332).dotnet test— 5604 passed / 0 failed inCelerity.Tests, plus 46 / 30 / 37 in the Ring / Sentinel / Cardinality test projects. 5717 total, all green.node scripts/benchmark_relevant_changes.js --self-test— 23 lexer cases pinned.node scripts/check_doc_anchors.js— 564 links across 23 files resolve.node scripts/check_dashboard_coverage.js— 155 cards across 47 collections wired.github-scriptbody passesnode --check; every multi-linerun:step passesbash -n.--shard-dry-runover shards 0/1/3/7 and the--shard-classesreplay path, including a roster naming a class the replaying side does not have (reported and skipped, no crash).run=true, so the full 8-shard matrix runs and exercises the new head/base roster handoff end-to-end. Worth watching that the base step logs the replayed slice and that no shard is cancelled.main— the publish path is unchanged, but the main-push run should still produce a complete report and refresh https://marius-bughiu.github.io/Celerity/dev/bench/.Closes #319. Closes #335. Closes #300.